Day17 結尾留了一句話:「Day18 會把 Agent 呼叫
brain-cli這件事本身講清楚。」今天要處理的,其實是一個從 Day06 就存在、但一直沒被正視的落差——/refine-inbox、/new-adr能正常運作,靠的是 Agent 讀懂brain scan/brain health印出的中文句子,不是靠任何結構化契約保證這份輸出格式不會變。
brain scan 印出 - [id] title (type/status),brain health 印出「共 N 篇孤立筆記,M 筆斷鏈」——這些格式從一開始就是為人類讀者設計的。Agent 能從裡面解析出檔名是否重複、孤立筆記數字有沒有下降,完全是自然語言理解幫忙補上的一層隱性契約。這件事在 Day15/16/17 的規模下沒出過問題,但把時間拉長來看,這種脆弱性只會越來越明顯:往後任何一次調整輸出文字的用詞、欄位順序、甚至只是多加一個空格,都可能悄悄讓某個 slash command 的判斷邏輯失準,而且沒有任何測試能驗證這層解析對不對。加上 obsidian-agent-brain 目前完全沒有 .claude/settings.json,Agent 每次呼叫 bin/brain 都要重新走一次工具授權提示——這在示範或重複執行整合流程時是不必要的摩擦。今天把這兩件事一次處理掉。
--json,不是把純文字改掉最直覺的做法是把三個子指令的輸出直接改成 JSON,一次到位。但這是一次貨真價實的 BREAKING 變更:Day15/16/17 已經寫好、驗證過的 /refine-inbox、/new-adr 全部假設輸出是那份中文純文字,一旦格式整個換掉,等於要重新驗證三個已經上線的 slash command 還能不能正常運作——風險完全不成比例。所以選擇讓兩種輸出模式並存:--json 是新增的旗標,不帶旗標時的行為(純文字輸出、結束碼規則)逐字元不變。往後新撰寫的 slash command 一律用 --json,既有的維持現狀,要不要回頭遷移留給未來視需要決定,這次不強求。
三個指令的 --json 輸出,刻意不引入純文字模式沒有的資訊:
scan --json:{"notes": [...], "errors": [...], "noteCount", "errorCount"},notes/errors 對應純文字模式逐行列出的同一批資料,noteCount/errorCount 對應結尾那句統計句。health --json:{"orphanNotes": [...], "brokenLinks": [...], "errors": [...], "orphanCount", "brokenLinkCount"},對應孤立筆記清單、斷鏈清單、解析失敗清單與統計數字。capture --json:{"path", "id"},對應純文字模式輸出的檔案路徑與 id。沒有多算一個純文字模式本來沒有的衍生統計,也沒有為了「JSON 看起來更完整」而加欄位。理由是兩種輸出模式必須保持資訊對等,否則遲早會出現其中一種變成事實來源、另一種變成沒人維護的過時簡化版。另外有一條硬規則:--json 模式下 stdout 只能是這個 JSON 物件本身,純文字模式的任何提示或錯誤訊息都不能混進來,否則 Agent 端的 JSON 解析會直接失敗;如果執行過程中真的發生非預期錯誤,就以非零結束碼結束、把錯誤訊息寫到 stderr,stdout 這時可以是空的,不去湊一個殘缺的 JSON。
health 偵測到斷鏈時非零、scan/capture 沿用既有規則——這條規則在 --json 與純文字兩種模式下必須完全一致,這也是為什麼新增的單元測試裡特別有一條 TestHealthCmd_JSONOutput_BrokenLinkStillExitsNonZero:確認换了輸出格式,非零結束碼依然會出現。Agent 判斷「這次操作要不要叫人工介入」,永遠只看結束碼,跟輸出格式無關。配合這一點,CLAUDE.md 新增了一段「結束碼判讀規則」:非零結束碼 SHALL 被視為異常訊號、SHALL 呈現給使用者,SHALL NOT 因為 stdout 內容看起來正常就自行判斷「應該沒事」而略過。這條規則其實在純文字時代就該存在,只是一直沒有寫下來。
agent-tool-bridge:這是呼叫契約,不是新的程式碼模組除了「輸出格式」,還有一組問題此前完全沒人寫下來:Agent 該怎麼找到 brain-cli 這個執行檔?該從哪個目錄執行?參數要怎麼傳?--json 解析失敗時該怎麼辦?這些問題的答案不屬於 brain-cli-core——那個 capability 只收斂 brain-cli 本身的輸入輸出行為,不管「呼叫它的人該怎麼做」。所以另外開了一個新的 capability agent-tool-bridge,定義四件事:優先找 bin/brain、不存在才退回 go run ./cmd/brain;固定假設在 repo 根目錄執行、路徑用相對於根目錄的寫法;參數一律透過 CLI 傳遞,不透過互動式的 stdin;--json 解析失敗時視為這次 subprocess 呼叫失敗,SHALL 明確回報給使用者,SHALL NOT 嘗試用純文字規則二次解析同一份輸出去湊答案,也不能悄悄假設「應該是成功了」。這四條講的都是「Agent 該怎麼做」,本身不需要新增任何程式碼——滿足它們靠的是 Agent 實際呼叫時的行為本身,這次驗證階段就是照著這四條實際執行一遍來確認的。
.claude/settings.json 只放行 bin/brain scan、bin/brain scan --json、bin/brain capture <任意內容>(含 --json 變體)、bin/brain health、bin/brain health --json 這幾種固定樣式,刻意不允許「bin/brain 後面接任意子指令或旗標」這種大範圍萬用字元。差別在於:萬用字元設定一旦寫下去,未來如果 brain-cli 新增了危險子指令,會因為這條舊設定被自動放行,而明確列舉法不會有這個問題——代價是往後新增或修改指令參數格式時,要記得同步更新 .claude/settings.json,容易被遺忘。這次選擇用「多一步同步成本」換「不會意外放行未預期指令」的安全性。
--json對 demo vault 執行 brain scan,純文字模式印出 8 篇筆記與 1 筆解析失敗:
- [20260815-090000] PARA 筆記法 (inbox-draft/seed)
- [20260819-221124] 季報草稿... (atomic-note/growing)
...
共 8 篇筆記,1 筆解析失敗
失敗:vault/README.md: 缺少 Frontmatter:檔案開頭必須是 "---"
--json 模式對同一個 vault 執行,輸出單一行 JSON(實際輸出不換行、不縮排,這裡為了閱讀方便重新排版):
{
"notes": [
{"id": "20260815-090000", "title": "PARA 筆記法", "type": "inbox-draft", "status": "seed", "path": "vault/00_Inbox/PARA 筆記法.md"},
...
],
"errors": [
{"path": "vault/README.md", "message": "缺少 Frontmatter:檔案開頭必須是 \"---\""}
],
"noteCount": 8,
"errorCount": 1
}
兩者的筆數、每篇筆記的 id/title/type/status/path、解析失敗的檔案與訊息完全對得上。brain health 也是同樣的對照:純文字模式印出「共 4 篇孤立筆記,0 筆斷鏈」,--json 模式輸出 {"orphanCount": 4, "brokenLinkCount": 0, ...},孤立筆記清單裡的 4 篇筆記標題與路徑逐一對應,結束碼在兩種模式下都是 0。brain capture "測試內容" --json 輸出 {"path": "vault/00_Inbox/測試內容.md", "id": "20260819-234927"},實際打開這個路徑,檔案的 frontmatter 裡 id 欄位就是這個值——JSON 回報的不是另一份摘要,是這次呼叫實際寫入的那份資料本身。
Day18 把「Agent 怎麼呼叫 brain-cli、怎麼讀懂它的回應」這件事第一次寫成明確的契約:--json 給了新指令一個不必靠自然語言理解去解析的輸出格式,agent-tool-bridge 把執行檔解析順序、工作目錄假設、錯誤處理方式這些此前只存在於「Agent 湊巧做對了」的細節訂下來,permission allowlist 則讓重複呼叫這些固定指令樣式不再每次都要重新授權。Day15/16 的 /refine-inbox、Day17 的 /new-adr 這次都不需要改一行,因為它們解析純文字輸出的既有邏輯完全沒被動到——這正是 Day18 選擇「新增而非取代」的意義。Day19 要把這些累積下來的指令實際串起來,跑一次 /refine-inbox、/new-adr 完整銜接的整合 demo,驗證 Stage 3 整段流程真的能無縫接軌。